Cloud Functions で SFTP サーバから GCS にファイル転送してみた。
こんにちは、みかみです。
やりたいこと
- SFTP サーバにアクセスしてファイルを取得するバッチ処理を実装したい
- バッチサーバは管理が面倒だしコストが嵩むので建てたくない
- Cloud Functions 関数から SFTP サーバにアクセスして、ファイルを GCS に転送したい
前提
環境構築時などでコマンドを使用していますが、Google Cloud SDK(CLI)の実行環境は準備済みの前提です。 本エントリでは、準備不要ですぐに使える Cloud Shell を使用しました。
SFTP サーバを準備
動作確認で使用する SFTP サーバを建てます。
以下の記事を参考にさせていただきましたmm
Google Cloud 管理コンソールのナビゲーションメニュー「Compute Engine」から「VM インスタンス」を選択して、インスタンスを作成します。 東京リージョンで一番小さいマシンタイプを選択し、他はデフォルトのままで作成しました。
インスタンスが起動したようなので、「SSH」リンクからインスタンスに接続してみます。
無事インスタンスに接続できました。アカウント情報を確認しておきます。
動作確認時に取得するファイルも作成しておきます。
mikami_yuki@test-sftp:~$ mkdir sample mikami_yuki@test-sftp:~$ cd sample/ mikami_yuki@test-sftp:~/sample$ vi test.txt
sample
ディレクトリ配下に test.txt
ファイルを作成し、hello! SFTP!!
と入力して保存しました。
続いて、SSH 認証鍵を作成します。 ローカル PC で以下のコマンドを実行して、認証ファイルを作成しました。
ssh-keygen -t rsa -m PEM -b 4096 -C "mikami_yuki@test-sftp" -f test_sftp
コマンドを実行するとパスフレーズを求められるので、任意のパスフレーズを入力します。
HL00710:.ssh mikami.yuki$ ssh-keygen -t rsa -m PEM -b 4096 -C "mikami_yuki@test-sftp" -f test_sftp Generating public/private rsa key pair. Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in test_sftp. Your public key has been saved in test_sftp.pub. The key fingerprint is: SHA256:tXLfEY6JLRe10dU5tF07+gHPWwbhIXlYatZyQceE580 mikami_yuki@test-sftp The key's randomart image is: +---[RSA 4096]----+ | .=X*O| | o*oXX| | . *oBB+| | . * B=oE| | S = =.o+o| | o + ..o+| | . .o | | | | | +----[SHA256]-----+
秘密鍵と公開鍵が作成されたことが確認できました。
HL00710:.ssh mikami.yuki$ ls -l | grep test_sftp -rw------- 1 mikami.yuki staff 3326 Nov 5 18:26 test_sftp -rw-r--r-- 1 mikami.yuki staff 747 Nov 5 18:26 test_sftp.pub
作成した公開鍵を開いて内容をコピーし、SFTPサーバに登録しておきます。
先ほど作成した VM インスタンスの編集画面に入り、編集画面の下の方にある「SSH 認証鍵」項目から、公開鍵を登録しました。
ローカル PC から Cyberduck を使って SFTP接続できるか確認してみます。
「サーバ」項目に VM インスタンスのパブリック IP を入力し、プライベートキーのパスを指定します。
接続してみると、先ほど VM インスタンス上に作成したディレクトリとファイルが確認できました。
SSH 認証キーを Secret Manager に保存
先ほどはローカル PC 上に保存してあるキーファイルのパスを指定して SFTP 接続してみましたが、Cloud Functions から SFTP サーバにアクセスする場合には、キーファイルをローカルに保存することはできません。 また、仮に Cloud Functions ではなく VM インスタンスからアクセスする場合でも、Secret Manager を使えばよりセキュアにキー情報を管理できます。
- Secret Manager のコンセプトの概要 | Secret Manager ドキュメント
- Secret Manager に保存した機密情報を、Cloud Functions の Python コードから取得してみた。 | DevelopersIO
SFTP 接続時に使用するプライベートキーとパスフレーズを、Secret Manager のシークレットバージョンを作成して保管します。
以下のコマンドで、シークレットを作成し、プライベートキーを保管するシークレットとバージョンを作成します。
gcloud secrets create test-sftp gcloud secrets versions add test-sftp --data-file="./test_sftp"
同様に、パスフレーズを保管するシークレットバージョンも作成しました。
gcloud secrets create test-key-pass gcloud secrets versions add test-key-pass --data-file="./key_pass"
作成したシークレットを確認します。
mikami_yuki@cloudshell:~/sftp (cm-da-mikami-yuki-258308)$ gcloud secrets list NAME: test-key-pass CREATED: 2021-11-05T09:46:58 REPLICATION_POLICY: automatic LOCATIONS: - NAME: test-sftp CREATED: 2021-11-05T09:31:36 REPLICATION_POLICY: automatic LOCATIONS: -
管理コンソールからも、シークレットバージョンが正常に作成されたことが確認できました。
続いて、作成したシークレットにアクセスするためのサービスアカウントを作成し、アクセス権を付与します。
gcloud iam service-accounts create test-sftp
mikami_yuki@cloudshell:~/sftp (cm-da-mikami-yuki-258308)$ gcloud iam service-accounts list | grep test-sftp EMAIL: test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com
gcloud secrets add-iam-policy-binding test-sftp \ --member="serviceAccount:test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com" \ --role="roles/secretmanager.secretAccessor" gcloud secrets add-iam-policy-binding test-key-pass \ --member="serviceAccount:test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com" \ --role="roles/secretmanager.secretAccessor"
このサービスアカウントを使って Cloud Functions を実行し、SFTP サーバから取得したファイルデータを GCS に Put する予定のため、GCS のアクセス権も付与しておきます。
gcloud projects add-iam-policy-binding cm-da-mikami-yuki-258308 \ --member="serviceAccount:test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com" \ --role="roles/storage.admin"
Cloud Functions 関数をデプロイして実行
環境の準備ができたので、いよいよ本題です。 SFTP サーバから GCS にファイルを転送する Cloud Functions 関数を作成します。
以下の Python コードを準備しました。
from google.cloud import secretmanager from google.cloud import storage import os import paramiko import io def get_secret_version(project_id, secret_id, version_id='latest'): client = secretmanager.SecretManagerServiceClient() name = f"projects/{project_id}/secrets/{secret_id}/versions/{version_id}" response = client.access_secret_version(request={"name": name}) payload = response.payload.data.decode('UTF-8') return payload def get_sftp_data(host, port, user, private_key, target_dir, dst_bucket): client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.WarningPolicy()) client.connect(host, port=port, username=user, pkey=private_key, timeout=5.0) sftp_connection = client.open_sftp() files = [_files for _files in sftp_connection.listdir(path=target_dir)] print(files) for file in files: file_path = f'{target_dir}{file}' obj = io.BytesIO() with obj as fp: sftp_connection.getfo(file_path, fp) stream = fp.getvalue().decode('UTF-8') put_blob(dst_bucket, file_path, stream, content_type='text/plain') return files def put_blob(bucket_name, file_path, stream, content_type='text/plain'): client = storage.Client() bucket = client.get_bucket(bucket_name) blob = bucket.blob(file_path) blob.upload_from_string(stream, content_type=content_type) def get_sftp_to_gcs(event, context): try: GCP_PROJECT = os.getenv('GCP_PROJECT', 'None') SECRET_ID_SFTP = os.getenv('SECRET_ID_SFTP', 'None') SECRET_ID_KEYPASS = os.getenv('SECRET_ID_KEYPASS', 'None') SFTP_HOST = os.getenv('SFTP_HOST', 'None') SFTP_PORT = os.getenv('SFTP_PORT', 'None') SFTP_USER = os.getenv('SFTP_USER', 'None') SFTP_DIR = os.getenv('SFTP_DIR', 'sample/') DST_BUCKET = os.getenv('DST_BUCKET', 'test-mikami') if not GCP_PROJECT or not SECRET_ID_SFTP or not SECRET_ID_KEYPASS or not SFTP_HOST or not SFTP_USER: raise Exception('Missing value in env.') key_stream = get_secret_version(GCP_PROJECT, SECRET_ID_SFTP) key_pass = get_secret_version(GCP_PROJECT, SECRET_ID_KEYPASS) private_key = paramiko.RSAKey.from_private_key(io.StringIO(key_stream.rstrip('\n')), key_pass.rstrip('\n')) files = get_sftp_data(SFTP_HOST, int(SFTP_PORT), SFTP_USER, private_key, SFTP_DIR, DST_BUCKET) print(f'put comp {files}') except Exception as e: raise
接続先の SFTP サーバのホスト情報やユーザーなどは、環境変数から取得します。 SSH 認証情報を Secret Manager から取得し、Python の SSH 接続用のライブラリ paramiko を使って、SFTPサーバにアクセスします。
SFTP サーバのファイルをオンメモリで取得したら、GCS オブジェクトとして出力します。
合わせて、関数で使用するライブラリの情報を記載した、requirements.txt ファイルも作成しました。
google-cloud-secret-manager>=2.7.2 google-cloud-storage>=1.42.0 paramiko>=2.8.0
以下のコマンドで、Cloud Functions 関数をデプロイします。
gcloud functions deploy get_sftp_to_gcs \ --region asia-northeast1 \ --runtime python37 \ --trigger-resource temp-test \ --trigger-event google.pubsub.topic.publish \ --service-account test-sftp@cm-da-mikami-yuki-258308.iam.gserviceaccount.com \ --set-env-vars SECRET_ID_SFTP=test-sftp,SECRET_ID_KEYPASS=test-key-pass,SFTP_HOST=34.85.13.90,SFTP_PORT=22,SFTP_USER=mikami_yuki,SFTP_DIR=sample/,DST_BUCKET=test-mikami
--service-account
オプションで先ほどシークレットへのアクセス権を付与したサービスアカウントを指定し、--set-env-vars
で関数内で取得する SFTP サーバ情報などの環境変数を指定します。今回は手動実行で動作確認するつもりなので、起動トリガーとなる --trigger-resource
には任意の Pub/Sub トピックを指定しました。
デプロイが完了したようなので、実行してみます。
mikami_yuki@cloudshell:~/sftp/cf (cm-da-mikami-yuki-258308)$ gcloud functions call get_sftp_to_gcs --region asia-northeast1 --data {} executionId: gttzo5havuz1 result: OK
result: OK
とのことで、正常に実行できた模様です。
本当に GCS にファイルが作成できたか、管理コンソールから確認してみます。
指定したパスにファイルは作成できているようです。
ファイルの中身も確認してみます。 ブラウザ上で開いてみると、
SFTP サーバ上で作成したファイルと同じ内容が確認できました。 無事、SFTP サーバから GCS に、ファイルを転送することができました!
まとめ(所感)
コストパフォーマンスのよいサーバレス処理を簡単に実装できてしまう Cloud Functions、ほんとに便利ですねーv
サーバレス VPC アクセスコネクタと Cloud NAT を使えば固定 IP にもできるので、IP 制限もこわくない!
実行時間とメモリの制限はあるものの、処理を分けて Pub/Sub 経由や GCS の Put イベントで複数の Cloud Functions 関数を連携するなど、アーキテクチャを考えるのもこれまた楽しv